Suspense & SWR

April 24, 2020

这篇是我在组内做的一次技术分享的讲稿。没怎么修改就直接分享出来了。

Suspense

前言

React 16.6 添加了一个 <Suspense> 组件,可以用来在 lazy load 的时候显示加载中的状态。

const ProfilePage = React.lazy(() => import("./ProfilePage")) // Lazy-loaded

// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

后来 React 想,这 Suspense 既然能用来等待 lazy load 的 Promise,其实也可以用来等待其他东西,比如请求数据的 Promise,因此就有了 Suspense for Data Fetching 这个特性。目前它仍是一个实验特性,官网上的文档也主要面向的是请求库的开发者(比如 swr 现在就适配 Suspense 了),对于大多数用户,React 官方文档仍然推荐使用 hooks 来请求数据

是什么?不是什么?能干什么?

Suspense 是一种“等待”机制,它作为一个组件,可以让你显式地声明当等待时应该渲染什么。

Suspense 不是一个请求库。它本身并不负责创建和管理请求。

Suspense 可以让请求库与 React 深度集成,请求库可以直接“告诉” React 它正在等待响应,而无需用户手动管理 loading 状态。

怎么用?

function Post({ id }) {
  const post = getPost(id)

  return <article>{post}</article>
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Post id={1} />
    </Suspense>
  )
}

这个 getPost(id) 是…同步的?

也许你感到这个写法符合直觉却又有些困惑,这就要说到数据获取的方式。

获取数据的方式

也许你在 React 文档中看过这部分的内容,我会用与官方文档稍有不同的方式讲解。这里有两种获取数据的方式:

  • 渲染后获取(传统的方式)
  • 渲染即获取(Suspense 的方式)
渲染后获取

渲染后获取就是我们最常写的方式,在 componentDidMount 或者等效的 useEffect 中进行数据获取

function Post({ id }) {
  const [post, setPost] = useState(null)

  useEffect(() => {
    fetchPost(id).then(data => setPost(data))
  }, [])

  if (!post) {
    return <div>Loading...</div>
  }

  return <article>{post}</article>
}

这种方式中,<Post> 组件首先进行首次渲染,渲染完成后(componentDidMount 阶段)开始发出请求。我们再来看另一种。

渲染即获取

function Post({ id }) {
  const post = getPost(id) // just works

  return <article>{post}</article>
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Post id={1} />
    </Suspense>
  )
}

这种方式中 <Post> 渲染的同时,getPost 的请求就发出了。目前看起来渲染即获取的方式只是比渲染后获取早了一点点时间,实际上里面的区别远不仅此。

两种方式的不同

放弃了 state

你会注意到渲染即获取的方案里,是没有组件内部 state 的。这就是为什么它给人的第一印象是干净、清爽、符合直觉。<Post> 的 UI 真正的是它 props 的映射了,而不是某个 state 的映射。

可难道也就只有更纯净了这种乌托邦式的区别吗?也并不是。放弃 state 同样能够避免一些问题,说不定你也曾遇到过。

1. Waterfall

请求瀑布。看如下使用内部 state 的例子:

function Profile() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetchUser().then(data => setUser(data))
  }, [])

  if (!user) {
    return <div>Loading profile...</div>
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <Posts />
    </div>
  )
}

function Posts() {
  const [posts, setPosts] = useState()

  useEffect(() => {
    fetchPosts().then(data => setPosts(data))
  }, [])

  if (!posts) {
    return <div>Loading posts...</div>
  }

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

这个例子里,posts 要等到 user 响应之后才开始获取。

2. Race condition

考虑如下渲染后获取的例子:

function Post({ id }) {
  const [post, setPost] = useState(null)

  useEffect(() => {
    fetchPost(id).then(data => setPost(data))
  }, [id])

  if (!post) {
    return <div>Loading...</div>
  }

  return <article>{post}</article>
}

function App() {
  const [id, setId] = useState(1)
  return (
    <div>
      <button onClick={() => setId(currentId => currentId + 1)}>
        Next post
      </button>
      <Post id={id} />
    </div>
  )
}

这个例子中 <App> 有一个 state,表示当前要看的文章 id,还有一个按钮用来修改这个 state。设想我们快速点击两次按钮,让 id 变成 2 再变成 3,<Post> 最终显示的是哪篇文章?由于异步请求的响应顺序无法确定,我们并不能保证 id 为 3 的时候,我们看到的就是文章 3。而没有了多余的 state,一切都变得顺畅而正确。

更早地获取数据

再讲一种情况,渲染即获取可以更早的获取数据。刚才不是讲过更早获取数据了么?这是一种不同的情况。

考虑如下例子,还是一个按钮修改 id,我们用渲染即获取的 <Post> 组件:

// Post.js
function Post({ post }) {
  return <article>{post}</article>
}

// App.js
const Post = React.lazy(() => import("./Post"))

function App() {
  const [id, setId] = useState(1)

  return (
    <div>
      <button onClick={() => setId(currentId => currentId + 1)}>
        Next post
      </button>
      <Post post={getPost(id)} />
    </div>
  )
}

这个例子中,我们可以同时获取 post 的内容和 <Post> 的代码,而不用等到 <Post> 下载完成才开始发起请求 post 内容。

这个 getPost(id) 是…同步的?

终于回到这个问题了,我们讲了这么多关于 Suspense for Data Fetching 所推崇的的“渲染即获取”,可究竟该怎么实现呢?

function Post({ id }) {
  const post = getPost(id)

  return <article>{post}</article>
}

虽然在 <Suspense> 的源码中没有找到它的实现,我们在官方示例的源码中大致推测出了它的机制:

// 不是示例的源码,但是是内意思
let post
let promise

function getPost(id) {
  if (post) return post
  if (promise) throw promise

  promise = fetchPost(id).then(data => (post = data))
  throw promise
}

没想到吧,这个 getPost(id) 方法多次调用可能返回不同的结果。它或者返回 post 的内容,或者抛出一个 Promise。经过试验,最终得出这样一个结论:当你的组件抛出一个 Promise 时,<Suspense> 就会认为,请求正在进行,并渲染 fallback。然后 React 调度器会适时地再次尝试渲染组件。

但这写法也太奇葩了

这就是为什么 React 关于这部分的文档是面向请求库作者,而非 React 用户的。

SWR

前言

日常开发中有时会遇到这些场景和处理方式:

相同的 URL (以及参数),缓存上一次的结果

设置一些常量来标识不同的请求,请求的结果根据标识放在 redux 里缓存,取数据的时候从 redux 取。这样来提高页面的响应效率,减少看到空页面的时间。

实际上,swr 已经帮你做好了。

是什么?不是什么?

swr 得名于 stale-while-revalidate 的缩写,它是 HTTP RFC 5861 中描述的一种 Cache-Control 扩展。大致意思是先返回缓存的响应,与此同时在后台请求新的响应,以提高响应速度,减少等待时间。虽然得名于此,swr 只是借用了它的概念,实际实现与 stale-while-revalidate 指令并无关系。

SWR 并不是一个十足的“请求库”。它主要针对的是数据获取的管理,而数据的更新、删除,它不管。

SWR is a React Hooks library for remote data fetching.

用法

import useSWR from "swr"

// fetch current user
const { data } = useSWR("/api/user")

也许你见过 SWR 这样的用法示例,心想这和普通的请求库做个 hook 没啥区别。那么我们来细细看下到底 SWR 是个什么东西。

API

const { data, error, isValidating, mutate } = useSWR(key, fetcher, options)
参数
  • key: 请求的标识
  • fetcher: 返回请求数据的异步方法
  • options: 更多配置项
返回值
  • data: 标识 key 对应的数据
  • error: 加载数据过程中抛出错误
  • isValidating: 是否正在请求或重新验证数据
  • mutate(data?, shouldRevalidate): 用于修改缓存数据

useSWR

Data Fetching

import useSWR from "swr"

async function fetchCurrentUser() {
  const { data } = await axios("/api/user")
  return data
}

function Profile() {
  const { data: user } = useSWR("currentUser", fetchCurrentUser)

  if (!user) {
    return <div>Loading profile...</div>
  }

  return <div>{user.name}</div>
}

useSWR 方法会为你返回已缓存的标识为 currentUser 的数据,并且通过 fetchCurrentUser 获取更新的数据并存入这个标识中。

注意,SWR 并非请求库,它并非接收 URL 作为第一个参数并向其地址发送请求。从上面例子就可看出 key 并非 URL,只是用于标识一个你需要的资源。不过,比起给你的每一个资源取名字,直接用 URL 来作为标识既方便又准确。

const { data: user } = useSWR("/api/user", fetchCurrentUser)

SWR 会将 key 作为参数传给 fetcher。既然我们使用了 URL 作为 key,那么封装一个通用的 request 方法作为 fetcher 也是个不错的选择。

async function request(url) {
  const { data } = await axios(url)
  return data
}

const { data: user } = useSWR("/api/user", request)

再加上 SWR 支持全局配置默认 fetcher,最终就变成了

const { data: user } = useSWR("/api/user")

Emmm,有内味了。看起来就像 SWR 是一个请求库一样,但你一定要清楚,其实并不是这样 😂。这一点很重要,对于你理解 SWR 的更多用法会有帮助。

Conditional Fetching & Dependent Fetching

key 传入 null 即代表不请求数据。

const { data: posts } = useSWR(user ? `/api/users/${user.id}/posts` : null)

如果你觉得这样有些反直觉,为什么 URL 为 null 就代表不发送请求?那么你就掉进了上面提到的误区。我们知道 key 是获取数据的标识,传入 null 表示我现在不取数据。

如果还是绕不清楚,换一种写法看看

async function fetchUserPosts(key) {
  const userId = key.match(/^posts by user (\d+)$/)[1]

  const { data } = await axios(`/api/users/${userId}/posts`)
  return data
}

const { data: posts } = useSWR(
  user ? `posts by user ${user.id}` : null,
  fetchUserPosts
)

至此你应该不会再混淆这个概念了。

花了这么大工夫搞清楚这个概念之后,我们继续来看 SWR 的用法。刚才讲了 key 可以是一个字符串,其实 key 还接受一些其他类型。比如当它是一个函数时,SWR 会用它的返回值作为存储标识。

// function as key
const { data: user } = useSWR(() => "/api/user")

// conditional
const { data: posts } = useSWR(() =>
  user ? `/api/users/${user.id}/posts` : null
)

SWR 对于函数 key 有一个特殊的处理,使得 dependent fetching 可以变得更美观流畅:

const { data: user } = useSWR(() => "/api/user")
const { data: posts } = useSWR(() => `/api/users/${user.id}/posts`)

user 还没有加载完时,postskey 函数会抛出异常。这时 SWR 就会不加载数据,就像 key 值为 null 一样。而当 user 加载完成,posts 就会开始加载。

Multiple Arguments

除了函数,key 还可以接收一个数组。就像 useCallbackuseEffectdeps 数组那样,key 数组的各个值依次相等则为相同的标识。key 数组的值会依次传入 fetcher 作为参数。

async function fetchUserPosts(_, userId) {
  return axios(`/api/users/${userId}/posts`)
}

const { data: posts } = useSWR(["posts by user", userId], fetchPostsByUser)

Mutate

Manually Revalidate

前面的示例都是初次获取数据,那怎么手动再次获取某个标识的数据呢?SWR 提供了 mutate 方法。

import useSWR, { mutate } from "swr"

function Profile() {
  const { data: user } = useSWR("currentUser")

  return (
    <div>
      <h2>{user.name}</h2>
      <button
        onClick={() => {
          mutate("currentUser")
        }}
      >
        Refresh
      </button>
    </div>
  )
}

当调用了 mutate(key),SWR 就会再次请求它对应的数据,并更新缓存。要注意,这里的 data: user 总是缓存中的内容。

或者,你也可以直接使用 useSWR 中返回的 mutate 方法,这样可以省去再次书写 key

function Profile() {
  const { data: user, mutate } = useSWR("currentUser")

  return (
    <div>
      <h2>{user.name}</h2>
      <button
        onClick={() => {
          mutate()
        }}
      >
        Refresh
      </button>
    </div>
  )
}

Mutation

我们前面说 SWR 本身不管更新数据。但是他允许我们修改缓存的数据。用的还是 mutate 方法,它接收第二个参数,用于在重新请求之前先修改缓存的数据。

function Todo({ id }) {
  const { data: todo, mutate } = useSWR(() => `/api/todos/${id}`)

  async function markTodoAsDone() {
    await axios.patch(`/api/todos/${todo.id}`, { done: true })
    mutate({ ...todo, done: true }) // 更新缓存数据,同时重新请求数据
  }

  if (!todo) {
    return <div>Loading...</div>
  }

  return (
    <div>
      {todo.content}
      <button onClick={markTodoAsDoen}>Mark as done</button>
    </div>
  )
}

很多时候,更新数据的请求会直接返回更新后的资源,这时我们可能希望更新缓存的同时不用再去重新验证资源。mutate 接收第三个参数来允许控制是否重新验证资源。

const { data: updated } = await axios.patch(`/api/todos/${todo.id}`, {
  done: true,
})
mutate(updated, false) // shouldRevalidate=false 表示无需重新验证资源

或者,你也可以用 Promise 来更新缓存,这也表示你不需要重新验证。

function updateTodo(id, data) {
  const { data: updated } = await axios.patch(`/api/todos/${todo.id}`, data);
  return updated;
}

mutate(updateTodo(todo.id, { done: true }));

Optimistic UI

也许你听说过 Optimistic UI 的概念。它描述的是当我请求更新/删除数据时,可以假设请求是成功的,并据此更新 UI;待请求完成,再根据实际结果更新 UI,这样提高页面响应速度。结合上面 mutate 的用法,其实就得到了 Optimistic UI。

mutate({ ...todo, done: true }, false) // 先乐观更新本地缓存,且不重新验证
mutate(updateTodo(todo.id, { done: true })) // 再用请求结果更新缓存

不止这些

上面只是介绍了 SWR 的一些 API 的常见用法,而 SWR 的能力可不止于此。

Focus Revalidation

当你重新聚焦到页面的时候,SWR 会自动重新验证数据。比如你在两个标签页打开了一个应用,然后在其中一个标签页修改了你的头像,当你切换到另一个标签页中时,新的头像已经加载好了。而这一机制无需你编写任何多余的代码。

Refetch on Interval

通过一个配置项开启周期性重新验证。这听起来自己实现也并不难,但别忘了,手动更新数据后重启定时器、页面离屏时暂停定时器,这些 SWR 都已帮你处理好。

从 request 到 SWR

key 的复用

我自己开发应用的时候习惯把请求按照 API 或者业务逻辑封装成一个个函数来调用,以便复用。

import { fetchUsers, updateUser } from "@/services/users"

function UserList() {
  const [users, setUsers] = useState()

  useEffect(() => {
    ;(async () => {
      const { data } = await fetchUsers()
      setUsers(data)
    })()
  }, [])

  return (
    <UserTable
      users={users}
      onEditUser={openEditUserDrawer}
      // ...
    />
  )
}

而在使用 SWR 的应用中,则应将 key 管理起来进行复用。

import * as resources from "@/constants/swr"

function UserList() {
  const { data: users } = useSWR(resources.users)

  return (
    <UserTable
      users={users}
      onEditUser={openEditUserDrawer}
      // ...
    />
  )
}

在前面讲到的 Conditional Fetching 和 Data Fetching 的用法中可以看出,当使用函数作为 key 等情况下时,往往会依赖组件内的变量/常量,这就要求可复用的 key 允许传入参数,来返回正确的 key。这样一来,一些 key 是字符串,一些是函数,使得 key 的管理变得不是很优雅。

export const user = "/api/user"
// conditional
export const userPosts = user => (user ? `/api/users/${user.id}/posts` : null)
// dependent
export const userPosts = user => () => `/api/users/${user.id}/posts`

function App() {
  const { data: user } = useSWR(resources.users)
  const { data: posts } = useSWR(resources.userPosts(user))
}

fetcher 的复用

当你处理好了 key 的复用,你会发现他们并没能完全代替原来复用的异步方法。你仍需要管理 fetcher,因为全局 fetcher 并没能应对你所有的 key。而在 Multiple Arguments 的例子中,像查询参数这类请求的参数应当展开成数组,这就需要 fetcher 的单独支持。

export const users = (page, pageSize, orderColumn, orderType, groupId) => [
  "/api/users",
  page,
  pageSize,
  orderColumn,
  orderType,
  groupId,
]

async function queryUsers(
  url,
  page,
  pageSize,
  orderColumn,
  orderType,
  groupId
) {
  return axios(url, {
    page,
    pageSize,
    orderColumn,
    orderType,
    groupId,
  })
}

function UserList() {
  const { data: users } = useSWR(
    resources.users(page, pageSize, orderColumn, orderType, groupId /*, ...*/),
    queryUsers
  )

  return <UserTable users={users} />
}

这里 fetcher 的 url 参数耦合度也很怪。

当你又最终想办法处理好了 key 和 fetcher 的复用,你发现,@/services 目录并没有消失,更新资源的异步函数仍然在里面。到头来这部分复用并没有帮你更高效更优雅地编写应用代码。

有没有 workaround?

也不是没有。我们来看 Multiple Arguments 的参数列表耦合问题。为什么会出现这个问题?是因为在函数中无法知道 key 数组各项分别对应什么参数。如何解决,就是不把参数拆开。可是传一个对象又不行,会重复触发更新,怎么办?

export const users = params => ["/api/users", JSON.stringify(params)]

async function queryUsers(url, params) {
  return axios(url, { params: JSON.parse(params) })
}

function UserList() {
  const { data: users } = useSWR(resources.users(params), queryUsers)

  return <UserTable users={users} />
}

JSON.stringify 将对象变成字符串就行了。这时你发现 queryUsers 的参数列表已经完全和调用者结耦,这部分处理 params 的机制可以被整合到全局 fetcher 里去了,不再需要单独的 fetcher。

export const users = params => ["/api/users", JSON.stringify(params)]

function UserList() {
  const { data: users } = useSWR(resources.users(params))

  return <UserTable users={users} />
}

机灵的你也许已经注意到,我的 key 里先进行一次 JSON.stringify,fetcher 里再进行一次 JSON.parse,到头来 axios 还要把它再 stringify 后拼到 URL 上去,是不是有点啰嗦?

我们可以用 qs 代替 JSON.stringify,这样我们的 key 数组就形成了漂亮的 [url, querystring] 范式,而这恰恰是区分资源最好的标识。

async function fetcher(url, querystring) {
  return axios(`${url}${querystring}`)
}

export const users = params => ["/api/users", qs.stringify(params)]

function UserList() {
  const { data: users } = useSWR(resources.users(params))

  return <UserTable users={users} />
}

但是使用范式会带来一点点代价。URL 相同而参数不同的情况下,会被认为是不同的资源,因此我们在表格页修改查询参数的时候,原先的查询结果无法留在界面上。

Suspense

讲了半天 Suspense,又讲了半天 SWR,他俩到底有啥关系呢?让我们回看这个问题:

这个 getPost(id) 是…同步的?

function Post({ id }) {
  const post = getPost(id)

  return <article>{post}</article>
}

你可能已经注意到,SWR 的 API 就实现了 Suspense 中推行的这一范式,让你摆脱 state 管理异步数据。并且,SWR 通过配置项支持了 Suspense 模式:

function Post({ id }) {
  const { data: post } = useSWR(`/api/posts/${id}`, { suspense: true })

  return <article>{post}</article>
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Post id={1} />
    </Suspense>
  )
}

当你开启了 suspense: true,SWR 就会在数据初次加载中抛出 Promise,触发 <Suspense>fallback

总结

这次介绍 Suspense 和 SWR,并非推销这两个技术,推行大家去使用它们。把它们放在一起讲,是因为它们引入了异步数据使用的思想上的更新。换个角度看问题,有时问题便不再是问题。

参考链接


Profile picture

Written by Doma who just migrated his blog to Gatsby.js. You should follow him on Twitter and GitHub.